/* Detect-zoom * ----------- * Cross Browser Zoom and Pixel Ratio Detector * Version 1.0.4 | Apr 1 2013 * dual-licensed under the WTFPL and MIT license * Maintained by https://github/tombigel * Original developer https://github.com/yonran */ //AMD and CommonJS initialization copied from https://github.com/zohararad/audio5js (function (root, ns, factory) { "use strict"; if (typeof (module) !== 'undefined' && module.exports) { // CommonJS module.exports = factory(ns, root); } else if (typeof (define) === 'function' && define.amd) { // AMD define("factory", function () { return factory(ns, root); }); } else { root[ns] = factory(ns, root); } }(window, 'detectZoom', function () { /** * Use devicePixelRatio if supported by the browser * @return {Number} * @private */ var devicePixelRatio = function () { return window.devicePixelRatio || 1; }; /** * Fallback function to set default values * @return {Object} * @private */ var fallback = function () { return { zoom: 1, devicePxPerCssPx: 1 }; }; /** * IE 8 and 9: no trick needed! * TODO: Test on IE10 and Windows 8 RT * @return {Object} * @private **/ var ie8 = function () { var zoom = Math.round((screen.deviceXDPI / screen.logicalXDPI) * 100) / 100; return { zoom: zoom, devicePxPerCssPx: zoom * devicePixelRatio() }; }; /** * For IE10 we need to change our technique again... * thanks https://github.com/stefanvanburen * @return {Object} * @private */ var ie10 = function () { var zoom = Math.round((document.documentElement.offsetHeight / window.innerHeight) * 100) / 100; return { zoom: zoom, devicePxPerCssPx: zoom * devicePixelRatio() }; }; /** * Mobile WebKit * the trick: window.innerWIdth is in CSS pixels, while * screen.width and screen.height are in system pixels. * And there are no scrollbars to mess up the measurement. * @return {Object} * @private */ var webkitMobile = function () { var deviceWidth = (Math.abs(window.orientation) == 90) ? screen.height : screen.width; var zoom = deviceWidth / window.innerWidth; return { zoom: zoom, devicePxPerCssPx: zoom * devicePixelRatio() }; }; /** * Desktop Webkit * the trick: an element's clientHeight is in CSS pixels, while you can * set its line-height in system pixels using font-size and * -webkit-text-size-adjust:none. * device-pixel-ratio: http://www.webkit.org/blog/55/high-dpi-web-sites/ * * Previous trick (used before http://trac.webkit.org/changeset/100847): * documentElement.scrollWidth is in CSS pixels, while * document.width was in system pixels. Note that this is the * layout width of the document, which is slightly different from viewport * because document width does not include scrollbars and might be wider * due to big elements. * @return {Object} * @private */ var webkit = function () { var important = function (str) { return str.replace(/;/g, " !important;"); }; var div = document.createElement('div'); div.innerHTML = "1
2
3
4
5
6
7
8
9
0"; div.setAttribute('style', important('font: 100px/1em sans-serif; -webkit-text-size-adjust: none; text-size-adjust: none; height: auto; width: 1em; padding: 0; overflow: visible;')); // The container exists so that the div will be laid out in its own flow // while not impacting the layout, viewport size, or display of the // webpage as a whole. // Add !important and relevant CSS rule resets // so that other rules cannot affect the results. var container = document.createElement('div'); container.setAttribute('style', important('width:0; height:0; overflow:hidden; visibility:hidden; position: absolute;')); container.appendChild(div); document.body.appendChild(container); var zoom = 1000 / div.clientHeight; zoom = Math.round(zoom * 100) / 100; document.body.removeChild(container); return{ zoom: zoom, devicePxPerCssPx: zoom * devicePixelRatio() }; }; /** * no real trick; device-pixel-ratio is the ratio of device dpi / css dpi. * (Note that this is a different interpretation than Webkit's device * pixel ratio, which is the ratio device dpi / system dpi). * * Also, for Mozilla, there is no difference between the zoom factor and the device ratio. * * @return {Object} * @private */ var firefox4 = function () { var zoom = mediaQueryBinarySearch('min--moz-device-pixel-ratio', '', 0, 10, 20, 0.0001); zoom = Math.round(zoom * 100) / 100; return { zoom: zoom, devicePxPerCssPx: zoom }; }; /** * Firefox 18.x * Mozilla added support for devicePixelRatio to Firefox 18, * but it is affected by the zoom level, so, like in older * Firefox we can't tell if we are in zoom mode or in a device * with a different pixel ratio * @return {Object} * @private */ var firefox18 = function () { return { zoom: firefox4().zoom, devicePxPerCssPx: devicePixelRatio() }; }; /** * works starting Opera 11.11 * the trick: outerWidth is the viewport width including scrollbars in * system px, while innerWidth is the viewport width including scrollbars * in CSS px * @return {Object} * @private */ var opera11 = function () { var zoom = window.top.outerWidth / window.top.innerWidth; zoom = Math.round(zoom * 100) / 100; return { zoom: zoom, devicePxPerCssPx: zoom * devicePixelRatio() }; }; /** * Use a binary search through media queries to find zoom level in Firefox * @param property * @param unit * @param a * @param b * @param maxIter * @param epsilon * @return {Number} */ var mediaQueryBinarySearch = function (property, unit, a, b, maxIter, epsilon) { var matchMedia; var head, style, div; if (window.matchMedia) { matchMedia = window.matchMedia; } else { head = document.getElementsByTagName('head')[0]; style = document.createElement('style'); head.appendChild(style); div = document.createElement('div'); div.className = 'mediaQueryBinarySearch'; div.style.display = 'none'; document.body.appendChild(div); matchMedia = function (query) { style.sheet.insertRule('@media ' + query + '{.mediaQueryBinarySearch ' + '{text-decoration: underline} }', 0); var matched = getComputedStyle(div, null).textDecoration == 'underline'; style.sheet.deleteRule(0); return {matches: matched}; }; } var ratio = binarySearch(a, b, maxIter); if (div) { head.removeChild(style); document.body.removeChild(div); } return ratio; function binarySearch(a, b, maxIter) { var mid = (a + b) / 2; if (maxIter <= 0 || b - a < epsilon) { return mid; } var query = "(" + property + ":" + mid + unit + ")"; if (matchMedia(query).matches) { return binarySearch(mid, b, maxIter - 1); } else { return binarySearch(a, mid, maxIter - 1); } } }; /** * Generate detection function * @private */ var detectFunction = (function () { var func = fallback; //IE8+ if (!isNaN(screen.logicalXDPI) && !isNaN(screen.systemXDPI)) { func = ie8; } // IE10+ / Touch else if (window.navigator.msMaxTouchPoints) { func = ie10; } //Mobile Webkit else if ('orientation' in window && typeof document.body.style.webkitMarquee === 'string') { func = webkitMobile; } //WebKit else if (typeof document.body.style.webkitMarquee === 'string') { func = webkit; } //Opera else if (navigator.userAgent.indexOf('Opera') >= 0) { func = opera11; } //Last one is Firefox //FF 18.x else if (window.devicePixelRatio) { func = firefox18; } //FF 4.0 - 17.x else if (firefox4().zoom > 0.001) { func = firefox4; } return func; }()); return ({ /** * Ratios.zoom shorthand * @return {Number} Zoom level */ zoom: function () { return detectFunction().zoom; }, /** * Ratios.devicePxPerCssPx shorthand * @return {Number} devicePxPerCssPx level */ device: function () { return detectFunction().devicePxPerCssPx; } }); })); var wpcom_img_zoomer = { zoomed: false, timer: null, interval: 1000, // zoom polling interval in millisecond // Should we apply width/height attributes to control the image size? imgNeedsSizeAtts: function( img ) { // Do not overwrite existing width/height attributes. if ( img.getAttribute('width') !== null || img.getAttribute('height') !== null ) return false; // Do not apply the attributes if the image is already constrained by a parent element. if ( img.width < img.naturalWidth || img.height < img.naturalHeight ) return false; return true; }, init: function() { var t = this; try{ t.zoomImages(); t.timer = setInterval( function() { t.zoomImages(); }, t.interval ); } catch(e){ } }, stop: function() { if ( this.timer ) clearInterval( this.timer ); }, getScale: function() { var scale = detectZoom.device(); // Round up to 1.5 or the next integer below the cap. if ( scale <= 1.0 ) scale = 1.0; else if ( scale <= 1.5 ) scale = 1.5; else if ( scale <= 2.0 ) scale = 2.0; else if ( scale <= 3.0 ) scale = 3.0; else if ( scale <= 4.0 ) scale = 4.0; else scale = 5.0; return scale; }, shouldZoom: function( scale ) { var t = this; // Do not operate on hidden frames. if ( "innerWidth" in window && !window.innerWidth ) return false; // Don't do anything until scale > 1 if ( scale == 1.0 && t.zoomed == false ) return false; return true; }, zoomImages: function() { var t = this; var scale = t.getScale(); if ( ! t.shouldZoom( scale ) ){ return; } t.zoomed = true; // Loop through all the elements on the page. var imgs = document.getElementsByTagName("img"); for ( var i = 0; i < imgs.length; i++ ) { // Wait for original images to load if ( "complete" in imgs[i] && ! imgs[i].complete ) continue; // Skip images that don't need processing. var imgScale = imgs[i].getAttribute("scale"); if ( imgScale == scale || imgScale == "0" ) continue; // Skip images that have already failed at this scale var scaleFail = imgs[i].getAttribute("scale-fail"); if ( scaleFail && scaleFail <= scale ) continue; // Skip images that have no dimensions yet. if ( ! ( imgs[i].width && imgs[i].height ) ) continue; if ( t.scaleImage( imgs[i], scale ) ) { // Mark the img as having been processed at this scale. imgs[i].setAttribute("scale", scale); } else { // Set the flag to skip this image. imgs[i].setAttribute("scale", "0"); } } }, scaleImage: function( img, scale ) { var t = this; var newSrc = img.src; // Skip slideshow images if ( img.parentNode.className.match(/slideshow-slide/) ) return false; // Scale gravatars that have ?s= or ?size= if ( img.src.match( /^https?:\/\/([^\/]*\.)?gravatar\.com\/.+[?&](s|size)=/ ) ) { newSrc = img.src.replace( /([?&](s|size)=)(\d+)/, function( $0, $1, $2, $3 ) { // Stash the original size var originalAtt = "originals", originalSize = img.getAttribute(originalAtt); if ( originalSize === null ) { originalSize = $3; img.setAttribute(originalAtt, originalSize); if ( t.imgNeedsSizeAtts( img ) ) { // Fix width and height attributes to rendered dimensions. img.width = img.width; img.height = img.height; } } // Get the width/height of the image in CSS pixels var size = img.clientWidth; // Convert CSS pixels to device pixels var targetSize = Math.ceil(img.clientWidth * scale); // Don't go smaller than the original size targetSize = Math.max( targetSize, originalSize ); // Don't go larger than the service supports targetSize = Math.min( targetSize, 512 ); return $1 + targetSize; }); } // Scale resize queries (*.files.wordpress.com) that have ?w= or ?h= else if ( img.src.match( /^https?:\/\/([^\/]+)\.files\.wordpress\.com\/.+[?&][wh]=/ ) ) { if ( img.src.match( /[?&]crop/ ) ) return false; var changedAttrs = {}; var matches = img.src.match( /([?&]([wh])=)(\d+)/g ); for ( var i = 0; i < matches.length; i++ ) { var lr = matches[i].split( '=' ); var thisAttr = lr[0].replace(/[?&]/g, '' ); var thisVal = lr[1]; // Stash the original size var originalAtt = 'original' + thisAttr, originalSize = img.getAttribute( originalAtt ); if ( originalSize === null ) { originalSize = thisVal; img.setAttribute(originalAtt, originalSize); if ( t.imgNeedsSizeAtts( img ) ) { // Fix width and height attributes to rendered dimensions. img.width = img.width; img.height = img.height; } } // Get the width/height of the image in CSS pixels var size = thisAttr == 'w' ? img.clientWidth : img.clientHeight; var naturalSize = ( thisAttr == 'w' ? img.naturalWidth : img.naturalHeight ); // Convert CSS pixels to device pixels var targetSize = Math.ceil(size * scale); // Don't go smaller than the original size targetSize = Math.max( targetSize, originalSize ); // Don't go bigger unless the current one is actually lacking if ( scale > img.getAttribute("scale") && targetSize <= naturalSize ) targetSize = thisVal; // Don't try to go bigger if the image is already smaller than was requested if ( naturalSize < thisVal ) targetSize = thisVal; if ( targetSize != thisVal ) changedAttrs[ thisAttr ] = targetSize; } var w = changedAttrs.w || false; var h = changedAttrs.h || false; if ( w ) { newSrc = img.src.replace(/([?&])w=\d+/g, function( $0, $1 ) { return $1 + 'w=' + w; }); } if ( h ) { newSrc = newSrc.replace(/([?&])h=\d+/g, function( $0, $1 ) { return $1 + 'h=' + h; }); } } // Scale mshots that have width else if ( img.src.match(/^https?:\/\/([^\/]+\.)*(wordpress|wp)\.com\/mshots\/.+[?&]w=\d+/) ) { newSrc = img.src.replace( /([?&]w=)(\d+)/, function($0, $1, $2) { // Stash the original size var originalAtt = 'originalw', originalSize = img.getAttribute(originalAtt); if ( originalSize === null ) { originalSize = $2; img.setAttribute(originalAtt, originalSize); if ( t.imgNeedsSizeAtts( img ) ) { // Fix width and height attributes to rendered dimensions. img.width = img.width; img.height = img.height; } } // Get the width of the image in CSS pixels var size = img.clientWidth; // Convert CSS pixels to device pixels var targetSize = Math.ceil(size * scale); // Don't go smaller than the original size targetSize = Math.max( targetSize, originalSize ); // Don't go bigger unless the current one is actually lacking if ( scale > img.getAttribute("scale") && targetSize <= img.naturalWidth ) targetSize = $2; if ( $2 != targetSize ) return $1 + targetSize; return $0; }); } // Scale simple imgpress queries (s0.wp.com) that only specify w/h/fit else if ( img.src.match(/^https?:\/\/([^\/.]+\.)*(wp|wordpress)\.com\/imgpress\?(.+)/) ) { var imgpressSafeFunctions = ["zoom", "url", "h", "w", "fit", "filter", "brightness", "contrast", "colorize", "smooth", "unsharpmask"]; // Search the query string for unsupported functions. var qs = RegExp.$3.split('&'); for ( var q in qs ) { q = qs[q].split('=')[0]; if ( imgpressSafeFunctions.indexOf(q) == -1 ) { return false; } } // Fix width and height attributes to rendered dimensions. img.width = img.width; img.height = img.height; // Compute new src if ( scale == 1 ) newSrc = img.src.replace(/\?(zoom=[^&]+&)?/, '?'); else newSrc = img.src.replace(/\?(zoom=[^&]+&)?/, '?zoom=' + scale + '&'); } // Scale LaTeX images or Photon queries (i#.wp.com) else if ( img.src.match(/^https?:\/\/([^\/.]+\.)*(wp|wordpress)\.com\/latex\.php\?(latex|zoom)=(.+)/) || img.src.match(/^https?:\/\/i[\d]{1}\.wp\.com\/(.+)/) ) { // Fix width and height attributes to rendered dimensions. img.width = img.width; img.height = img.height; // Compute new src if ( scale == 1 ) newSrc = img.src.replace(/\?(zoom=[^&]+&)?/, '?'); else newSrc = img.src.replace(/\?(zoom=[^&]+&)?/, '?zoom=' + scale + '&'); } // Scale static assets that have a name matching *-1x.png or *@1x.png else if ( img.src.match(/^https?:\/\/[^\/]+\/.*[-@]([12])x\.(gif|jpeg|jpg|png)(\?|$)/) ) { // Fix width and height attributes to rendered dimensions. img.width = img.width; img.height = img.height; var currentSize = RegExp.$1, newSize = currentSize; if ( scale <= 1 ) newSize = 1; else newSize = 2; if ( currentSize != newSize ) newSrc = img.src.replace(/([-@])[12]x\.(gif|jpeg|jpg|png)(\?|$)/, '$1'+newSize+'x.$2$3'); } else { return false; } // Don't set img.src unless it has changed. This avoids unnecessary reloads. if ( newSrc != img.src ) { // Store the original img.src var prevSrc, origSrc = img.getAttribute("src-orig"); if ( !origSrc ) { origSrc = img.src; img.setAttribute("src-orig", origSrc); } // In case of error, revert img.src prevSrc = img.src; img.onerror = function(){ img.src = prevSrc; if ( img.getAttribute("scale-fail") < scale ) img.setAttribute("scale-fail", scale); img.onerror = null; }; // Finally load the new image img.src = newSrc; } return true; } }; wpcom_img_zoomer.init(); ; /*! * jQuery Cookie Plugin v1.3.1 * https://github.com/carhartl/jquery-cookie * * Copyright 2013 Klaus Hartl * Released under the MIT license */ (function (factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as anonymous module. define(['jquery'], factory); } else { // Browser globals. factory(jQuery); } }(function ($) { var pluses = /\+/g; function raw(s) { return s; } function decoded(s) { return decodeURIComponent(s.replace(pluses, ' ')); } function converted(s) { if (s.indexOf('"') === 0) { // This is a quoted cookie as according to RFC2068, unescape s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); } try { return config.json ? JSON.parse(s) : s; } catch(er) {} } var config = $.cookie = function (key, value, options) { // write if (value !== undefined) { options = $.extend({}, config.defaults, options); if (typeof options.expires === 'number') { var days = options.expires, t = options.expires = new Date(); t.setDate(t.getDate() + days); } value = config.json ? JSON.stringify(value) : String(value); return (document.cookie = [ config.raw ? key : encodeURIComponent(key), '=', config.raw ? value : encodeURIComponent(value), options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE options.path ? '; path=' + options.path : '', options.domain ? '; domain=' + options.domain : '', options.secure ? '; secure' : '' ].join('')); } // read var decode = config.raw ? raw : decoded; var cookies = document.cookie.split('; '); var result = key ? undefined : {}; for (var i = 0, l = cookies.length; i < l; i++) { var parts = cookies[i].split('='); var name = decode(parts.shift()); var cookie = decode(parts.join('=')); if (key && key === name) { result = converted(cookie); break; } if (!key) { result[name] = converted(cookie); } } return result; }; config.defaults = {}; $.removeCookie = function (key, options) { if ($.cookie(key) !== undefined) { // Must not alter options, thus extending a fresh object... $.cookie(key, '', $.extend({}, options, { expires: -1 })); return true; } return false; }; })); ; /** * jQuery qToggle Plugin * Allows you to toggle (hide/show) DOM elements just by applying data attributes to a controlling HTML element. * Supports many jQuery animations like slideToggle, fadeToggle, etc., and also support animation settings such as duration, easing, and callbacks. * @developer Nathan Letsinger * @since 1.0 * @requires jQuery 1.7.2 * * Copyright 2012 Nathan Letsinger * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. **/ (function($){ jQuery.fn.qToggle = function(options){ // settings with defaults var settings = jQuery.extend({ 'data' : {}, // object - passed into event (reserved for later use) 'effect' : 'toggle', // string - the animation effect on target 'events' : 'click.qToggle', // string - the event on control (namespaced) 'context' : '[data-qtoggle-selector]', // string - a selector that defines a control 'selector' : 'qtoggle-selector', // string - the name of the data that holds selector string of target 'targets' : 'prev', // string - the default target if none is specified in settings.selector 'innerHTML' : '', // string - html to insert into control's text node. Empty string for no change 'eventArgs' : { // object - arguments passed to the effect function 'duration' : null, // int|string - duration of animation in miliseconds or keywords 'fast','slow',etc. 'easing' :'linear', // string - the animation effect, 'linear' or 'swing' are the only options in native jQuery but other plugins may provide other options 'callback' : null // string|function - the function to call when animation is complete } }, options); // listen for events on the selector this.on( settings.events, settings.context, settings.data, function(event){ // explicitly stop default behavior of events on this control event.preventDefault(); // explicitly stop bubbling event.stopPropagation(); /* @var object - a whitelist of available effects and DOM transversal keywords */ var defaults = { 'effects' :[ 'toggle', 'slideToggle', 'fadeToggle', 'hide' , 'show' , 'fadeOut', 'fadeIn', 'slideUp' , 'slideDown' ], 'transversals' : [ 'prev' ,'next', 'parent', 'siblings', 'nextAll', 'prevAll' ,'children' ] }; /* @var object - the jQuery selection of the toggle control element */ var control = jQuery(this); // @todo throw an error if given effect is not in our whitelist /* @var string - the name of the effect function desired, 'toggle' by default */ var effect = control.data('qtoggle-effect') || settings.effect; effect = (jQuery.inArray( effect, defaults.effects ) === -1) ? defaults.effects[0] : effect; /* @var string - the selector string for our targeted DOM element to be effected */ var targets = control.data( settings.selector ) || settings.targets; /* @var string - the new value of the control's innerHTML, if any */ var innerHTML = ( control.data('qtoggle-text') || settings.innerHTML ); /* @var string|int - the timing for the animation */ var duration = control.data('qtoggle-duration') || settings.eventArgs.duration; /* @var string - the timing for the animation */ var easing = control.data('qtoggle-easing') || settings.eventArgs.easing; /* @var function|null - the callback function, if any */ var callback = control.data('qtoggle-callback') || settings.eventArgs.callback; callback = (typeof window[callback] === 'function' ) ? window[callback] : null // possibly update the innerHTML of the control element if( innerHTML ) { control.data( 'qtoggle-text', control.html() ); control.html( innerHTML ); } // apply the desired effect on the DOM targets: // first we check if we are using a DOM transversal keyword like 'next' or 'prev' if( jQuery.inArray(targets, defaults.transversals) !== -1 ) control[targets]()[effect]( duration, easing, callback ); // then we check if we should effect the control itself else if( targets === 'this' || targets === 'self' ) control[effect]( duration, easing, callback ); // otherwise we select the target using css selectors else jQuery(targets)[effect]( duration, easing, callback ); return false; // stop propogations and default events });// end this.on() // I'm a chainable function return this; }})( jQuery );; /** * Universal Grist Javascript * * Mostly used for getting and setting user-specific data * * Requires jQuery, jquery.cookie * Copyright (c) 2009-2112 Grist (grist.org) */ var GRIST = GRIST || {}; jQuery(function(){ // Get what we know about the reader from localStorage GRIST.READER.init(); // Track subscriptions GRIST.SUBSCRIPTION_TRACKING.init(); GRIST.NEWSLETTER_ARRIVAL_CAPTURE.init(); // Track visits GRIST.VISIT.init(); GRIST.VISIT.MONTHLY_COUNTER.init(); //GRIST.VISIT.PAGEVIEWS.init(); // Track donations GRIST.DONATION_TRACKING.init(); // Track readers who reach the end of articles GRIST.DEPTH_TRACKING.init(); // Track discovery mechanism usage GRIST.DISCOVERY_TRACKING.init(); }); /** * GRIST.READER is an object that represents a reader. * you can access: * * email * zip - note that zip is no longer collected * subs * visits * is_subscriber * is_subscriber_facebook * is_subscriber_email * is_subscriber_twittter * is_subscriber( sub_name ) * is_donor * * @ToDo: visit_this_month count * @ToDo: Last visit * @ToDo: Last visit this month */ GRIST.READER = { init: function(){ if( ! GRIST.STORAGE.is_enabled ) return false; // Populate the object from local storage this.update(); // Listen for changes jQuery('body').on( 'grist_storage_change', jQuery.proxy( this.update, this ) ); }, /** * Pulls information about the reader from local storage */ update: function(){ this.email = GRIST.STORAGE.get('email'); this.subs = GRIST.STORAGE.get('subs'); this.zip = GRIST.STORAGE.get('zip'); this.visits = GRIST.STORAGE.get('visits'); this.is_subscriber = this.is_subscribed_to(); this.is_subscriber_facebook = this.is_subscribed_to('facebook'); this.is_subscriber_email = this.is_subscribed_to('email'); this.is_subscriber_twitter = this.is_subscribed_to('twitter'); this.is_donor = (function(){ var donations = GRIST.STORAGE.get('donations'); return ( jQuery.isArray( donations ) && donations.length > 0 ); })(); }, /** * Is the user a subscriber? If a specific subscription name is provided * we check for that string in the array of subscriptions * @param string sub_name name of the subscription to search for * @return bool */ is_subscribed_to: function( sub_name ){ if( ! jQuery.isArray( this.subs ) ) return false; if( typeof sub_name === 'undefined' ) return this.subs.length > 0; var is_subscribed = false; jQuery.each( this.subs, function(i, sub){ is_subscribed = sub.indexOf( sub_name ) !== -1; // break out of the loop if we found a subscription return ! is_subscribed; }); return is_subscribed; }, /** * Marks the user as having a series of subscriptions ('email-daily', 'fb', 'twitter', etc) * @param array list of subscription names */ add_subscriptions: function( new_subscriptions ){ var current_subscriptions = this.subs || [], unique_subscriptions = []; if( ! jQuery.isArray( new_subscriptions ) || ! jQuery.isArray( current_subscriptions ) ) return; // combine newsletter lists jQuery.merge( current_subscriptions, new_subscriptions ); // remove duplicates jQuery.each( current_subscriptions, function( i, el ){ if( jQuery.inArray( el, unique_subscriptions ) === -1) unique_subscriptions.push( el ); }); GRIST.STORAGE.set( 'subs', unique_subscriptions ); this.subs = unique_subscriptions; } }; // GRIST.READER /** * Methods to get and set different values in local storage */ GRIST.STORAGE = { key_prefix: 'grist_', /** * Gets a value from storage * @return object */ get: function( key ){ if( typeof key === 'undefined') return false; key = this.key_prefix + key; try{ return JSON.parse( localStorage.getItem( key ) ); } catch(e){ return false; } }, /** * Sets a value in storage * @param string key * @param mixed val */ set: function( key, val ){ if( typeof key === 'undefined' || typeof val === 'undefined' ) return false; key = this.key_prefix + key; try{ localStorage.setItem( key, JSON.stringify( val ) ); jQuery('body').trigger( 'grist_storage_change' ); return true; } catch(e){ return false; } }, /** * Adds values to an already existing array * * @param string key The key of the array stored in localStorage * @param array new_vals The values to be added to the array */ add_to_array: function( key, new_vals ){ if( typeof key !== 'string' || ! jQuery.isArray( new_vals ) || new_vals.length < 1 ) return ; // get current array var current_array = this.get( key ) || []; // combine arrays var combined_array = jQuery.merge( current_array, new_vals ); this.set( key, combined_array ); }, /** * Increments a counter stored in local storage * @param string key Key of the counter in localStorage */ increment_counter: function( key ){ if( typeof key !== 'string' ) return; var counter = parseInt( this.get( key ), 10 ); if( isNaN( counter ) ) counter = 0; this.set( key, counter + 1 ); }, /** * Does this browser support localStorage? * @see - http://diveintohtml5.info/detect.html#storage */ is_enabled: (function(){ try { return 'localStorage' in window && window['localStorage'] !== null; } catch(e){ return false; } })() }; // GRIST.STORAGE /** * Grist Subscription Tracking * @requires jQuery, 'grist-universal-js', jquery.cookie * * Listens for subscriptions to email, facebook, or twitter * * When a user subscribes: * * Fire KM events: * - subscribe * -- subscription_type: email, facebook, twitter * -- subscription_location: flyout, sidebar * - subscribe-[type] * - subscribe-location-[location] * * Assign KM properties: * - subscriber * - subscriber-[type] * - subscriber-[specific type] ex. subscriber-daily * * Fire GA event * - Category: Subscriptions * - Action: URL subscribed from * - Label: subscription_type * * Extra GA events (if data available): * - subscribed_in_location * * ID user in KM with their email * * Store user information in localStorage * - add to subscriptons array [ email-daily, fb, email-food, twitter ] * - email * */ GRIST.SUBSCRIPTION_TRACKING = { /** * Listens for subscribes and old cookies */ init: function(){ jQuery('body').on('grist_subscribe', jQuery.proxy( this.record_sub_and_identify, this) ); // Convert old cookies to new local storage this.legacy_cookie_conversion(); }, /** * * Records subscription in analytics and * applies properties to the user * * @param event e * @param object sub_data The subscription data. ex. { sub_type: 'email', subs: ['daily'] } * * @see GRIST.EMAIL_SUB.get_subscription_data() for exact format of email subscription data */ record_sub_and_identify: function(e, sub_data){ if( typeof sub_data !== 'object' || ! sub_data.hasOwnProperty('sub_type') || ! sub_data.hasOwnProperty('subs') ) return; this.record_subscription( sub_data ); this.mark_as_subscriber( sub_data ); }, /** * Records subscription in analytics * @param object sub_data The subscription data. ex. { sub_type: 'email', subs: ['daily'] } */ record_subscription: function( sub_data ){ // Record subscription in GA // @ToDo: review category, label, action _gaq.push(['_trackEvent', 'subscriptions', document.URL, sub_data.sub_type, 0, true]); // Record subscription in KM var km_event_props = {}; // Record subscribe location if there is one if( sub_data.hasOwnProperty( 'sub_location' ) && sub_data.sub_location ){ km_event_props.subscription_location = sub_data.sub_location; _kmq.push(['record', 'subscribe-location-' + sub_data.sub_location]); _gaq.push(['_trackEvent', 'subscription_meta', 'subscribed_in_location', sub_data.sub_location, 0, true]); } _kmq.push(['record', 'subscribe', km_event_props]); _kmq.push(['record', 'subscribe-' + sub_data.sub_type]); if( sub_data.sub_type === 'email' ){ // Fire a subscribe event for each specific subscription jQuery.each( sub_data.subs, function( i, val){ _kmq.push(['record', 'subscribe-' + val]); }); } }, /** * Marks the user as a subscriber in local storage and kissmetrics * @param object sub_data The subscription data. ex. { sub_type: 'email', subs: ['daily'] } */ mark_as_subscriber: function( sub_data ){ var km_user_props = { subscriber: 1 }, subscriber_label_prefix = 'subscriber-'; // Mark the user as subscriber of this type. ie. subscriber-facebook, subscriber-email km_user_props[ subscriber_label_prefix + sub_data.sub_type ] = 1; // If user subscribed to email, assign KM props and prefix subscriptions with 'email' if( sub_data.sub_type === 'email' ){ // Add each subscription to the KM user as a property jQuery.each( sub_data.subs, function( i, val){ km_user_props[ subscriber_label_prefix + val ] = 1; }); // Prefix subs with the subscription type, i.e. 'email-daily' instead of 'daily' sub_data.subs = this.prefix_subs_array( sub_data.sub_type, sub_data.subs ); } // if email subscription // Push the properties to KM _kmq.push(['set', km_user_props ]); // Add subscriptions to persistent storage GRIST.READER.add_subscriptions( sub_data.subs ); // Record email if( sub_data.hasOwnProperty('sub_email') ){ _kmq.push(['identify', sub_data.sub_email ]); GRIST.STORAGE.set( 'email', sub_data.sub_email ); } }, /** * Fires a subscription event that other plugins can listen for and act on * * The properties 'sub_type' and 'subs' are required * * @param object sub_data ex. { sub_type: 'email', subs: ['daily', 'food'] } */ announce_subscribe: function( sub_data ){ if( typeof sub_data !== 'object' || ! sub_data.hasOwnProperty('sub_type') || ! sub_data.hasOwnProperty('subs') ) return; jQuery('body').trigger( 'grist_subscribe', sub_data ); }, /** * Adds a prefix to the subscriptions * @param string prefix [description] * @param array subs ex. ['daily', 'food'] * @return array Prefixed subscription list ex. ['email-daily', 'email-food'] */ prefix_subs_array: function( prefix, subs ){ jQuery.each( subs, function( i, val ){ subs[i] = prefix + '-' + val; }); return subs; }, /** * Looks for the old 'grist_subscriber' cookie * and write the appropriate values to localStorage before * deleting it */ legacy_cookie_conversion: function(){ try{ legacy_cookie = jQuery.cookie( 'grist_subscriber' ); if( ! legacy_cookie ) return; // Parse old cookie var sub_data = legacy_cookie.split('|'), email = sub_data[0], lists = sub_data[1]; // Break up lists into an array lists = lists.split(','); // Prefix lists with subscription type this.prefix_subs_array( 'email', lists ); // Write in new data GRIST.STORAGE.set( 'email', email ); GRIST.READER.add_subscriptions( lists ); // Delete old cookie jQuery.removeCookie('grist_subscriber', { path: '/' }); } catch(e) {} } }; // GRIST.SUBSCRIPTION_TRACKING /** * Grist Newsletter Arrival Capturing * * Checks for the utm_source=newsletter query var and identifies the reader as * a subscriber of that newsletter in persistent storage * * When a user arrives from an email: * Assign KM properties * - subscriber * - subscriber-[type] * - subscriber-[specific-type] ex. subscriber-daily * * ID user in KM with their email * * Store user information in localStorage * - add to subscriptons array ex. [food] * - email * */ GRIST.NEWSLETTER_ARRIVAL_CAPTURE = { init: function(){ // Check if we're coming from a newsletter. No referrers allowed. if( document.referrer !== '' || GRIST.HELPERS.get_URL_param( 'utm_source' ) !== 'newsletter' ) return; var sub_data = { sub_type: 'email' }, newsletter_name = this.get_newsletter_name(), email = GRIST.HELPERS.get_URL_param( 'sub_email' ); if( ! newsletter_name ) return; sub_data.subs = [newsletter_name]; if( email ) sub_data.sub_email = email; GRIST.SUBSCRIPTION_TRACKING.mark_as_subscriber( sub_data ); }, /** * Gets and sanitizes the the newsletter that the user came from * @return str|bool Newsletter name on success, false if not a valid name */ get_newsletter_name: function(){ var newsletter_name = GRIST.HELPERS.get_URL_param( 'utm_campaign' ); var whitelisted_newsletters = [ 'daily', 'weekly', 'business-tech', 'climate-energy', 'living', 'food' ]; // The supplied newsletter must be one of our accepted values if( jQuery.inArray( newsletter_name, whitelisted_newsletters ) === -1 ) return false; else return newsletter_name; } }; // GRIST.NEWSLETTER_ARIVAL_CAPTURE /** * Grist Visit * * Sets a cookie to indicate a running visit to the site that will be * updated on each page view * * This cookie will expire after 30 minutes of inactivity * If you want to know if this particular pageview is the start * of a new visit, check against GRIST.VISIT.is_new_visit */ GRIST.VISIT = { init: function(){ // Requires that cookies be enabled and jQuery.cookie plugin if( typeof jQuery.cookie !== 'function' || ! GRIST.HELPERS.are_cookies_enabled ) return; // Set the params this.cookie_name = "grist_visit"; this.visit_length = 30; //minutes this.is_new_visit = false; // If the visit cookie does not exist, start a new visit if ( typeof jQuery.cookie( this.cookie_name ) === 'undefined' ) this.start_new_visit(); // Create/refresh the visit cookie jQuery.cookie(this.cookie_name, '1', { expires: this.calculate_expiration_date(), path:'/' }); }, /** * @return Date A Date object a certain number of minutes from now */ calculate_expiration_date: function(){ var date = new Date(); date.setTime( date.getTime() + ( this.visit_length * 60 * 1000) ); return date; }, /** * Initiates a new visit */ start_new_visit: function(){ this.is_new_visit = true; // Increment universal visit counter GRIST.STORAGE.increment_counter('visits'); // Reset the running log of things that happened during this visit GRIST.STORAGE.set('visit_events', []); } }; // GRIST.VISIT /** * Count the number of visits we are getting this month from this person * and store them in a cookie */ GRIST.VISIT.MONTHLY_COUNTER = { init: function(){ // Requires that cookies be enabled and jQuery.cookie plugin if( typeof jQuery.cookie !== 'function' || ! GRIST.HELPERS.are_cookies_enabled ) return; this.cookie_name = 'grist_vtm'; //visits_this_month // How many visits have we had from this person this month? this.visits_this_month = parseInt( jQuery.cookie( this.cookie_name ), 10 ); // If we get back NaN it means that the cookie is not set, so this is their first visit this month if( isNaN( this.visits_this_month ) ) this.visits_this_month = 1; else // Otherwise, add one to their visit count for this month this.visits_this_month++; // Only continue if this is the start of a new visit if( ! GRIST.VISIT.is_new_visit ) return; // If this is their second visit this month, fire analytics events if( this.visits_this_month == 2){ _kmq.push(['record', 'visited-twice-in-month']); _gaq.push(['_trackEvent', 'Retention', 'visited-twice-in-month', '', 0, true]); } // Update visit cookie jQuery.cookie( this.cookie_name, this.visits_this_month, { expires: this.get_end_of_month(), path:'/' } ); }, /** * @return Date the last moment of the last day of this month */ get_end_of_month: function(){ var d = new Date(); return new Date( d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59 ); } }; // GRIST.VISIT.MONTHLY_COUNTER /** * Fires an event once for each type of page viewed during visit */ GRIST.VISIT.PAGEVIEWS = { init: function(){ // Requires that we have pageview data if( typeof GRIST.PAGEVIEW === 'undefined' || ! GRIST.PAGEVIEW.hasOwnProperty( 'type' ) ) return; // Requires that cookies be enabled and jQuery.cookie plugin if( typeof jQuery.cookie !== 'function' || ! GRIST.HELPERS.are_cookies_enabled ) return; // Requires local storage if( ! GRIST.STORAGE.is_enabled ) return; var visit_events = GRIST.STORAGE.get('visit_events'); // If there is no local storage key 'visit_events', bail if( jQuery.type( visit_events ) !== 'array' ) return; var visit_event = 'visited-' + GRIST.PAGEVIEW.type; // If we've recorded an event of this type during this visit, bail if( jQuery.inArray( visit_event, visit_events ) !== -1 ) return; // Add it to visit event log so we don't record a second viewing GRIST.STORAGE.add_to_array( 'visit_events', [ visit_event ] ); // Record visit event _kmq.push(['record', visit_event ]); } // init }; // GRIST.VISIT.PAGEVIEWS /** * Looks for a cookie from services.grist.org and records * a donation for this user in localstorage */ GRIST.DONATION_TRACKING = { init: function(){ //Get the cookie jQuery.cookie.json = true; var donation_data = jQuery.cookie( 'grist_donor' ); if( ! donation_data || typeof donation_data !== 'object' ) return; // Legacy support if( donation_data.hasOwnProperty('t') ) donation_data = { time: donation_data.t * 1000 }; // Record the information in local storage GRIST.STORAGE.add_to_array( 'donations', [donation_data] ); // Delete the cookie jQuery.removeCookie( 'grist_donor', { path: '/', domain: 'grist.org' } ); } }; // GRIST.DONATION_TRACKING /** * Records an event when readers reach the end of an article */ GRIST.DEPTH_TRACKING = { init: function(){ // Requires that we have pageview data if( typeof GRIST.PAGEVIEW === 'undefined' || ! GRIST.PAGEVIEW.hasOwnProperty( 'type' ) ) return; var depth_event_recorded = false, story_type = GRIST.PAGEVIEW.type, $end_of_article = jQuery('#grist-end-of-article'); // If we're not on an article page, bail if( $end_of_article.length < 1 ) return; // Look for the end of article tag on each scroll // scroll event polling @see http://ejohn.org/blog/learning-from-twitter/ var didScroll = false; jQuery(window).on('scroll.grist_depth_tracking', function(){ didScroll = true; }); var depth_scroll_interval = setInterval(function() { if( depth_event_recorded ) return; if ( didScroll ) { didScroll = false; if( GRIST.HELPERS.is_el_on_screen( $end_of_article ) ){ // Set flags, clear interval, and remove event handler to free up resources depth_event_recorded = true; clearInterval( depth_scroll_interval ); jQuery(window).off('scroll.grist_depth_tracking'); // Fire analytics events _kmq.push(['record', 'reached-end-of-article']); _kmq.push(['record', 'reached-end-of-article-' + story_type]); _gaq.push(['_trackEvent', 'Retention', 'reached-end-of-article', story_type, 0, true]); // We are particularly interested in deeper green articles, // so fire a special event if one of those is finished var deep_green_types = ['story', 'news', 'people', 'basics']; if( jQuery.inArray( story_type, deep_green_types ) === -1 ) return; _kmq.push(['record', 'reached-end-of-article-deep-green']); _gaq.push(['_trackEvent', 'Retention', 'reached-end-of-article', 'deep-green', 0, true]); } } }, 250); } }; /** * Tracks usage of our discovery mechanisms */ GRIST.DISCOVERY_TRACKING = { init: function(){ // Listen for people clicking on our discovery sources this.listen_for_clicks(); //Listen for people landing on our pages from another grist page this.listen_for_landing(); }, /** * Listens for clicks on discovery sources like outbrain and skyboxes * and fires analytics events when they occur */ listen_for_clicks: function(){ var action = 'discovery-click', _this = this; // A list of discovery sources. Those that are injected dynamically need // to have an injection_scope so we can listen for clicks after their injection var discovery_sources = [ { label: 'story-infinite-scroll', container: '.infinite-scroll-item', link_selector: 'a', injection_scope: '.infinite-stories' }, { label: 'responsive-skybox-large', container: '#skyboxes', link_selector: 'a', injection_scope: '#main-nav' }, { label: 'outbrain', container: '.ob_dual_left', link_selector: 'a', injection_scope: '#recommendations' }, { label: 'responsive-skybox-small', container: '.small-menu-bar .recommended-stories', link_selector: 'a' }, { label: 'special-features-widget', container: '#zone-homepage-special-features', link_selector: 'a' }, { label: 'desktop-skybox', container: '#masthead ', link_selector: '.skybox' }, { label: 'umbra-widget', container: '.umbra-widget', link_selector: 'a' }, { label: 'most-viewed-widget', container: '#widget-most-viewed ', link_selector: 'a' } ]; jQuery.each( discovery_sources, function(i, discovery_source){ // A callback function for our discovery listeners function discovery_click_callback(e){ _this.record_event(action, discovery_source.label); } if( discovery_source.hasOwnProperty('injection_scope') ){ jQuery(discovery_source.injection_scope).on('click', discovery_source.container + ' ' + discovery_source.link_selector, discovery_click_callback); } else { jQuery(discovery_source.container).find(discovery_source.link_selector).on('click', discovery_click_callback); } }); }, /** * Listens for people landing on a grist page from another grist page */ listen_for_landing: function(){ var referrer = document.referrer; // If this is a direct visit, bail if( ! referrer) return; // If this is a refresh, bail if( referrer === document.URL ) return; // Get the domain name of the referrer var referral_components = referrer.split('/'); if( referral_components.length < 3 ) return; var referral_domain = referral_components[2]; // Check that it contains one of our accepted domain names var is_from_grist = (referral_domain.indexOf('grist.org') !== -1); var is_from_local_outbrain = (referral_domain.indexOf('traffic.outbrain.com') !== -1); // If this isn't from an internal source, bail if( !is_from_grist && !is_from_local_outbrain ) return; _gaq.push(['_trackEvent', 'Retention', 'discovery-landing', referrer, 0, true]); }, /** * Records an analytics event * @param {str} action Action name i.e. 'discovery-click' * @param {str} type Action type i.e. 'skybox' */ record_event: function( action, type ){ if( typeof action === 'undefined' || typeof type === 'undefined') return; _kmq.push(['record', action]); _kmq.push(['record', action + '-' + type]); _gaq.push(['_trackEvent', 'Retention', action, type, 0, true]); } }; // GRIST.DISCOVERY_TRACKING /** * Utility functions */ GRIST.HELPERS = { /** * Are cookies enabled? * @see - http://sveinbjorn.org/cookiecheck * @return bool */ are_cookies_enabled: (function(){ var cookieEnabled = (navigator.cookieEnabled) ? true : false; if (typeof navigator.cookieEnabled == "undefined" && !cookieEnabled) { document.cookie="testcookie"; cookieEnabled = (document.cookie.indexOf("testcookie") != -1) ? true : false; } return (cookieEnabled); })(), /** * Gets a parameter from the url * @see - http://stackoverflow.com/questions/1403888/get-url-parameter-with-jquery * @param str param name of the param to get * @return str|bool value of the parameter if set, false if not set */ get_URL_param: function( param ) { return (RegExp(param + '=' + '(.+?)(&|$)').exec(location.search) || [false, false])[1]; }, /** * Checks to see if an element is on the screen * * @see http://stackoverflow.com/questions/487073/check-if-element-is-visible-after-scrolling * * @param jQuery DOM Element el * @return bool */ is_el_on_screen: function( $el ){ if( typeof $el === 'undefined' || $el.length === 0 ) return false; var docViewTop = jQuery(window).scrollTop(); var docViewBottom = docViewTop + jQuery(window).height(); var elemTop = $el.offset().top; var elemBottom = elemTop + $el.height(); return ((elemBottom <= docViewBottom) && (elemTop >= docViewTop)); }, /** * Returns the canonical page url for the current page, if set in a tag * @return string the url of the canonical version of the page */ get_canonical_link: function() { return jQuery('link[rel=canonical]').attr('href'); } }; // GRIST.HELPERS; /* * addthis.js * * * Initiates a social tracker, which fires Google Analytics and * KISSMetrics events when people like or follow Grist or share a story. * * @requires 'grist-universal-js' */ var GRIST = GRIST || {}; if( typeof( addthis ) === 'object' && typeof( addthis.addEventListener ) === 'function' ) { // Start listening for likes and shares addthis.addEventListener('addthis.menu.share', grist_social_tracker); } /** * Records GA/KISSmetrics events for shares/subscriptions * * @param addthis_evt The addthis event (a like or a share) */ function grist_social_tracker( addthis_evt ){ // check which service the user shared with var event_service = addthis_evt.data.service; // don't record print or email events if( event_service === 'print' || event_service === 'email' ) return; var event_name = 'unknown type'; // subscribe or share event var event_atts; var subscription_type = grist_get_addthis_subscription_type( addthis_evt ); if( subscription_type ){ // if this is a subscription, announce it GRIST.SUBSCRIPTION_TRACKING.announce_subscribe({ sub_type: subscription_type, subs: [subscription_type] }); } else { // if this is a share, record the type (tweet, facebook, googleplus, etc) and the url shared event_name = 'share'; event_atts = { 'share_type': event_service }; _kmq.push(['record', event_name, event_atts]); _gaq.push(['_trackEvent', event_name, event_service, document.URL, 0, true]); } } // end grist_km_traffic_quality_tracker() /** * Check what type of subscription just occured * * @param addthis_evt The addthis event * @return string "facebook" if the user liked Grist.org or "twitter" if they followed Grist.org on twitter * @return boolean false if it is a different kind of AddThis event (such as a page specific like) * */ function grist_get_addthis_subscription_type( addthis_evt ){ var share_type = addthis_evt.data.service; var share_url = addthis_evt.data.url; if( share_type === "facebook_like" && ( share_url === "https://www.facebook.com/grist.org" || share_url === 'http://grist.org/' ) ){ return 'facebook'; } else if( share_type === "twitter_follow_native" && share_url === "http://grist.org/" ){ return 'twitter'; } else { return false; } };